Socket assign is empty in handle_in

Can anyone help me with socket assigns? I’m doing channel testing, and have a socket = assign(socket, :user_id, user_id) in the join function. Later in one of the handle_in functions, IO.inspect(socket.assigns) gives me an empty map. Please note that I’m not checking this in the tests, but in the channel code only.

For reference, the handle_in code is like this:

def handle_in("notification", %{"command" => "get_unread"} = payload, socket) do
  %{"user_id" => user_id} = payload
  data = get_unread_notifications_for_user(user_id)
  IO.inspect(socket.assigns) # empty map!
  {:reply, {:ok, %{"data" => data}}, socket} 
end

The join function is longish, but the part that does the assign is like this:

changeset = RoomUser.changeset(%RoomUser{room_id: room_id, user_id: user_id, app_id: app_id, status: 1})    

if changeset.valid? do    
  Repo.insert!(changeset)

  # send a push notifcation to room
  broadcast socket, "room", %{"type" => "user_joined", "user_id" => user_id, "app_id" => app_id}

  # bind the user id and app id to the socket
  socket = assign(socket, :user_id, user_id)
  socket = assign(socket, :app_id, app_id)
  #IO.puts("=== In the join function ===")
  #IO.inspect(socket.assigns) # when uncommented, this one shows the correct assigns
  {:ok, socket}
end

What could be the reason for this? Is this something to do with channel testing? If yes, how can I test for events like users going offline, as I need to take important actions on sudden exit?

Is that if the very last thing in your join-function or is there some code after that?

I’ve been doing some more looking around in the code and I think I might have spotted the problem. Allow me to explain and then please suggest something. :slight_smile:

Basically, it’s a multi-user, multi-room chatting app. I created events to create room, join room, etc., which are handled by various handle_in. But the problem was that a user couldn’t do handle_in without having a socket established, so I changed the workflow to require every join to join a “default” topic implicitly. This default room is known only to the front-end and back-end. Now that the connection is established, other rooms, etc., can be created.

Now in my tests, the setup function is like this:

setup do
    {:ok, _, socket} =
      socket("user_id", %{})
      #Subscribe to the "default" room so that we can send commands over events
      |> subscribe_and_join(RoomChannel, "default")
    {:ok, socket: socket}    
  end

The code I posted is from the join function for “real” rooms. As you can see, nothing gets passed as user_id and app_id in the above function, which explains why I’m getting a blank maps elsewhere. Moreover, when I inspected the socket, it always yielded “default” as the topic.

Does that mean I’ve screwed up room joining? What might be the possible reasons for not seeing the other topics (rooms) in sockets? Also, is assign linked to a particular room?

I’m sorry if it sounds confusing or is not the best way to achieve things. Please bear with me. :slight_smile: I will add more code if needed.

It does sound like the topic is not being set from the javascript side yeah. :slight_smile:

Just for a quick recap, in case it helps in any way:

  1. The webpage initiates a websocket/longpolling connection to the server at a url.
  2. The server starts up a Socket to handle the connection, anything you assign on the socket that you return here will be on the assign everywhere for this connection.
  3. The javascript then connects to a topic.
  4. The server then gets the packet of the topic request and creates a new Channel process where the socket from the Socket is passed in here, it can further add ‘more’ assigns to the returned socket here, of which will be available to all the functions within the Channel.
  5. Multiple channels can be joined and they can add/remove assigns to their copies of the main socket all they want with no interference.
1 Like

Hi, as I said earlier, this is part of channel testing and there is no JS client yet. I think I should add more code to clarify. :slight_smile:

The chatting scheme is as follows:

  1. The user is made to implicitly join a “default” room at start. This room is not shown, but enables the socket connection. This was done to allow the user to send various commands. I’m sorry if it’s a poor approach but I couldn’t think of anything better.
  2. The user can then send request to join more rooms.

Here’s the relevant code from my channel tests:

  setup do
    {:ok, _, socket} =
      socket("user_id", %{})
      #Subscribe to the "default" room so that we can send commands over events
      |> subscribe_and_join(RoomChannel, "default")
    {:ok, socket: socket}    
  end

And the unit test:

test "Front-end can get the list of pending user invites", %{socket: socket} do
    # create a room and a user and app
    ref = push socket, "room", %{"command" => "create", "user_id" => 20, "app_id" => 20, "room_name" => "Room 20"}
    assert_reply ref, :ok, %{"room_id" => room_id}

    # create another room and user
    ref = push socket, "room", %{"command" => "create", "user_id" => 21, "app_id" => 21, "room_name" => "Room 21"}
    assert_reply ref, :ok, %{"room_id" => room_id_temp}

    # first user joins room
    ref = push socket, "room", %{"command" => "join", "room_id" => room_id, "user_id" => 20, "app_id" => 20}
    # we must make sure the process has replied to us 
    assert_reply ref, :ok

    # first user invites the second user
    ref = push socket, "invite", %{"command" => "send_invite", "room_id" => room_id, "sending_user_id" => 20, "sending_app_id" => 20, "receiving_user_id" => 21, "receiving_app_id" => 21}
    assert_reply ref, :ok

    ref = push socket, "invite", %{"command" => "get_pending", "user_id" => 21, "app_id" => 21}
    assert_reply ref, :ok, %{"data" => [item]}

    assert %{"sending_user_id" => 20, "sending_app_id" => 20}  = item    
  end

So basically, here one user sends invite to the other user, and we check the pending invites for the other user.

Here are the relevant functions from the channels code:

  @doc"""
  This is the first room that the user must join. This is an empty room, created only so that users get a valid connection to the channel system and can start sending commands like 'create room'
  """
  def join("default", data, socket) do
    {:ok, socket}
  end

@doc"""
  Handle joining of rooms
  """
  def handle_in("room", %{"command" => "join"} = data, socket) do
    # send join request to function `join` in same module
    %{"room_id" => room_id} = data
    # create a pattern so that a different channel is created for the room
    response = RoomChannel.join("room_#{room_id}", data, socket)
    {:reply, response, socket}
  end

@doc"""
  Function that handles joining of rooms by users. The only basic check it performs is whether the room is exists or not. If not, it creates one, setting the requesting user as the creator of the room.
  
  Note that nothing special is as such needed to "join" a room. We simply return :ok to the socket, and the Phoenix PubSub system does the rest whenever messages are posted in the room.
  """
  def join("room_" <> room_id, data, socket) do
    %{"user_id" => user_id, "app_id" => app_id} = data

    {room_id, _} = Integer.parse(room_id)    

    # Create user and app if they don't exist
    verify_or_create_user(user_id)
    verify_or_create_app(app_id)

    # First, check if room exists
    room = Repo.get_by(Room, id: room_id)
    
    case room do
      nil ->
         {:error, %{"reason" => "Room doesn't exist"}}
      _ ->
        # It's time to create association betwen user and room
        # The problem here, however, is that many_to_many
        # relations don't work if you also need to update a 
        # non-key field on the join-through model.
        # Therefore, we'll work around this problem by saving the
        # RoomUser model directly. See the following link for getting
        # convinced that this is indeed the right solution: 
        # https://elixirforum.com/t/many-to-many-where-joining-model-needs-to-be-updated-also/1785

        changeset = RoomUser.changeset(%RoomUser{room_id: room_id, user_id: user_id, app_id: app_id, status: 1})

        if changeset.valid? do    
          Repo.insert!(changeset)

          # send a push notifcation to room
          broadcast socket, "room", %{"type" => "user_joined", "user_id" => user_id, "app_id" => app_id}

          # bind the user id and app id to the socket
          socket = assign(socket, :user_id, user_id)
          socket = assign(socket, :app_id, app_id)
          #IO.puts("=== In the join function ===")
          #IO.inspect(socket)
          {:ok, socket}
        end        
    end
  end

Note that the previous function does assign some data to the socket. Finally, I did an inspect in the function that gets data related to pending invites, to make sure the socket carried the user id and app id:

  def handle_in("invite", %{"command" => "get_pending"} = payload, socket) do
    IO.inspect(socket) # show me the socket!
    %{"user_id" => user_id, "app_id" => app_id} = payload
    invites = Repo.all from i in Invite,
              join: r in assoc(i, :room),
              where: i.receiving_user_id == ^user_id and i.receiving_app_id == ^app_id and i.status == 1,
              preload: [room: r]
    
    data = relevant_data_for_invites(invites)
    {:reply, {:ok, %{"data" => data}}, socket}
  end

And when I run the test, the socket is:

%Phoenix.Socket{assigns: %{}, channel: Elemental.TxChat.RoomChannel,
 channel_pid: #PID<0.441.0>, endpoint: Elemental.TxChat.Endpoint, handler: nil,
 id: "user_id", joined: true, pubsub_server: Elemental.TxChat.PubSub,
 ref: #Reference<0.0.3.1532>, serializer: Phoenix.ChannelTest.NoopSerializer,
 topic: "default", transport: Phoenix.ChannelTest,
 transport_name: :channel_test, transport_pid: #PID<0.439.0>}

Which leads to my questions:

  1. Why didn’t the socket get assigned the values?
  2. If it’s not possible because of some weird mocking behavior of the channel testing module (or something else), how can I test for events where user connection breaks unexpectedly?

And of course, pardon the bad code. I’m still learning the ropes. :stuck_out_tongue:

I do precisely the same thing, “Messenger:control”, rooms are in “Messenger:room:”. ^.^

As for the assign, unsure, I’ve not mocked that part of my code (I instead do per-function testing instead of integration testing, I should do both…).

Is def join("room_" <> room_id, data, socket) do the definition for RoomChannel.join/3? If yes, you should change handle_in to do something like:

case RoomChannel.join("room_#{room_id}", data, socket) do
    {:ok, socket} -> {:reply, :ok, socket}
    {:error, reason} -> {:reply, {:error, %{message: reason}}, socket}
end

rather than

response = RoomChannel.join("room_#{room_id}", data, socket)
{:reply, response, socket}

Otherwise you’re sending the socket returned by join/3 as a reply to the client, rather than using it to update the Channel’s state.

Also note how you’re handling the case where the changeset is invalid:

    changeset = RoomUser.changeset(%RoomUser{room_id: room_id, user_id: user_id, app_id: app_id, status: 1})

    if changeset.valid? do    
      Repo.insert!(changeset)

      # send a push notifcation to room
      broadcast socket, "room", %{"type" => "user_joined", "user_id" => user_id, "app_id" => app_id}

      # bind the user id and app id to the socket
      socket = assign(socket, :user_id, user_id)
      socket = assign(socket, :app_id, app_id)
      #IO.puts("=== In the join function ===")
      #IO.inspect(socket)
      {:ok, socket}
    end    

You’re not doing anything in the case where it’s not valid (there is no “else”), so the value of the last statement will be returned, which is “changeset” itself. You probably want to do case Repo.insert(changeset) do and handle both cases.

Gosh. Sorry for leaving it hanging, folks. The project I was working on was kind of trashed and so there as no motivation for me to follow this through. Can I somehow close this thread for the time being?

1 Like