Adding Authors to Chat
Our users can sign up, sign in, and chat. But all messages are anonymous. Our next step is to set users' emails as their handles so that messages have an author. After all, it's tough to follow a conversation without knowing who wrote what.
To start, let's consider which test to run. I don't think we need a new test since the core of the functionality — users chatting — is already in place. Instead, we can update the feature test where two users chat to include authors of messages.
Let's update our existing test to how we want it to work from now on. It will have some test failures that we can follow until the test passes. Let's get started.
Adding authors to UserCanChatTest
Open up your test/chatter_web/features/users_can_chat_test.exs
file. If you
recall, the test has two users signing in with their unique emails via our
sign_in/2
helper and posting messages. All we need to update is the
assert_has(session, message(text))
to account for the author's email. We'll go
with a straightforward way to do that; we'll expect the message to include the
author's email. Change the following:
# test/chatter_web/features/user_can_chat_test.exs
test "user can chat with others successfully", %{metadata: metadata} do
room = insert(:chat_room)
user1 = insert(:user)
user2 = insert(:user)
session1 =
metadata
|> new_session()
|> visit(rooms_index())
|> sign_in(as: user1)
|> join_room(room.name)
session2 =
metadata
|> new_session()
|> visit(rooms_index())
|> sign_in(as: user2)
|> join_room(room.name)
session1
|> add_message("Hi everyone")
session2
- |> assert_has(message("Hi everyone"))
+ |> assert_has(message("Hi everyone", author: user1))
|> add_message("Hi, welcome to #{room.name}")
session1
- |> assert_has(message("Hi, welcome to #{room.name}"))
+ |> assert_has(message("Hi, welcome to #{room.name}", author: user2))
end
# code omitted
- defp message(text) do
- Query.data("role", "message", text: text)
+ defp message(text, author: author) do
+ message = "#{author.email}: #{text}"
+ Query.data("role", "message", text: message)
end
end
We change the message/1
function to take an additional argument: it now takes
the expected text and the author. We combine those two arguments to form the
text for Wallaby's query — think of something like "John: Hi everyone".
Keep in mind that each user expects their browser session to show the opposite
user's message: session1
asserts that user2
sent the message, and session2
asserts that user1
sent the message.
If you stopped running webpack, start it now. We'll be dealing with JavaScript, so we want those files to recompiled as we make changes.
Now, let's run our test!
$ mix test test/chatter_web/features/user_can_chat_test.exs
1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.
code: |> assert_has(message("Hi everyone", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:27: (test)
Finished in 6.3 seconds
1 test, 1 failure
Wallaby cannot find the new text with user1
's email because we have not
changed our implementation. But the error doesn't tell us what we need to do
next. Nevertheless, we have a good guess: the implementation where we render new
messages. So let's update our JavaScript code to render an author's email along
with the message body.
Open up chat_room.js
and find where we receive the "new_message" event. We
will act as though our payload already has an author
key along with the
body
:
# assets/js/chat_room.js
channel.on("new_message", payload => {
let messageItem = document.createElement("li");
messageItem.dataset.role = "message";
- messageItem.innerText = payload.body;
+ messageItem.innerText = `${payload.author}: ${payload.body}`
messagesContainer.appendChild(messageItem);
});
Now rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.
code: |> assert_has(message("Hi everyone", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:27: (test)
Finished in 6.3 seconds
1 test, 1 failure
Hmmm, 🤔. Unfortunately, no JavaScript errors were raised. If there were any,
Wallaby would have notified us. It's a good opportunity to use Wallaby's
take_screenshot/1
helper. Place it on the pipeline right before
assert_has(message(user1, "Hi everyone"))
:
# test/chatter_web/features/user_can_chat_test.exs
session1
|> add_message("Hi everyone")
session2
+ |> take_screenshot()
|> assert_has(message(user1, "Hi everyone"))
|> add_message("Hi, welcome to #{room.name}")
Now rerun the test again, but this time when it fails, open up the screenshot it
saved in screenshots/
(your screenshot's filename will be different than
mine):
$ open screenshots/1581068254007662000.png
The screenshot shows our "chat room 0" with one message: undefined: Hi
everyone
. So payload.author
in chat_room.js
is undefined
. That's
expected. Delete the screenshot Wallaby saved, and remove the
take_screenshot/1
function from our test. Let's fix the undefined
author by
adding it to the payload
sent from our channel. That's where we'll go next.
Setting the author in outgoing payload
Let's up ChatterWeb.ChatRoomChannel
and write the code as we wish it worked
already.
Our handle_in/3
function currently takes a message from JavaScript (we call it
payload
) and broadcasts it without modification. But now we want to
augment the broadcast with the author's email. The question is, how should we get
the author's email?
We have a couple of options:
- We could pass the author's email in the
payload
from JavaScript, or - We could set the author's email in our
socket
when a user first connects to theUserSocket
Since a user's email does not vary between messages, the second option seems like an excellent fit for our needs.
With that decided, we can assume that our socket
in ChatRoomChannel
will
contain the author's email. So, update our handle_in/3
function to create a
new outgoing_payload
based on the incoming payload and the email in
socket.assigns
:
# lib/chatter_web/channels/chat_room_channel.ex
def handle_in("new_message", payload, socket) do
- broadcast(socket, "new_message", payload)
+ author = socket.assigns.email
+ outgoing_payload = Map.put(payload, "author", author)
+ broadcast(socket, "new_message", outgoing_payload)
{:noreply, socket}
end
end
Now let's rerun our feature test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)
03:41:58.375 [error] GenServer #PID<0.627.0> terminating
** (KeyError) key :email not found in: %{}
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:9: ChatterWeb.ChatRoomChannel.handle_in/3
(phoenix 1.5.4) lib/phoenix/channel/server.ex:316: Phoenix.Channel.Server.handle_info/2
(stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
(stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: "new_message", join_ref: "3", payload: %{"body" => "Hi everyone"}, ref: "4", topic: "chat_room:chat room 0"}
1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.
code: |> assert_has(message("Hi everyone", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:27: (test)
Finished in 7.0 seconds
1 test, 1 failure
Great. We have a helpful error: ** (KeyError) key :email not found in: %{}
. We
need that email key in our socket. Let's dive into UserSocket.connect/3
to set
the email next.
Putting the email on the socket assigns
As usual, let's write our code as we wish it existed. In this case, we'd like to get the user's email from the front-end:
# lib/chatter_web/channels/user_socket.ex
- def connect(_params, socket, _connect_info) do
- {:ok, socket}
+ def connect(%{"email" => email}, socket, _connect_info) do
+ {:ok, assign(socket, :email, email)}
end
+ def connect(_, _, _), do: :error
We receive an email in the params
and assign it to the socket. We also add a
second connect/3
function that will return :error
if the params
do not
have an email.
Let's run our test again:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 2 files (.ex)
1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
** (Wallaby.JSError) There was an uncaught javascript error:
webpack-internal:///../deps/phoenix/priv/static/phoenix.js 499 WebSocket connection to 'ws://localhost:4002/socket/websocket?vsn=2.0.0' failed: Error during WebSocket handshake: Unexpected response code: 403
code: |> visit(rooms_index())
stacktrace:
(wallaby 0.26.2) lib/wallaby/chrome/logger.ex:8: Wallaby.Chrome.Logger.parse_log/1
(elixir 1.11.0) lib/enum.ex:786: Enum."-each/2-lists^foreach/1-0-"/2
(wallaby 0.26.2) lib/wallaby/driver/log_checker.ex:12: Wallaby.Driver.LogChecker.check_logs!/2
(wallaby 0.26.2) lib/wallaby/browser.ex:963: Wallaby.Browser.visit/2
test/chatter_web/features/user_can_chat_test.exs:12: (test)
Finished in 1.5 seconds
1 test, 1 failure
This is both good news and bad news. The good news is that we've made it one
step further: we're no longer getting the error (KeyError) key :email not
found in: %{}
from our ChatRoomChannel
. Setting the email in the socket
on
when connecting worked.
The bad news is that we get a somewhat cryptic webpack-internal error. Nevertheless, we have a good sense of what to do next. Our JavaScript does not send a user's email when connecting to the socket. Let's update that next.
Connecting to a socket with an email
To send the user's email when connecting to the socket, we have to pass it from
Elixir to JavaScript. There are many ways we could accomplish this. I'll take
the following approach: if a user is authenticated, we'll set the email in
conn.assigns
, then we'll grab that value and set it on window.email
so that
JavaScript has access to it.
Let's work backwards and write the JavaScript code as though window.email
was
set. Open up socket.js
, and update the connection to pass params
:
# assets/js/socket.js
-let socket = new Socket("/socket", {params: {}})
+let socket = new Socket("/socket", {params: {email: window.email}})
In our app layout, add the following script tag before our JavaScript tag
loading the app.js
file:
# lib/chatter_web/templates/layout/app.html.eex
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= render @view_module, @view_template, assigns %>
</main>
+ <script>window.email = "<%= assigns[:email] %>";</script>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
Now rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)
1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.
code: |> assert_has(message("Hi everyone", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:27: (test)
Finished in 6.3 seconds
1 test, 1 failure
We're past the cryptic error, but this error doesn't point us in any direction.
Nevertheless, if we're following our train of thought correctly, we should
suspect the new error happens because we never set the email in the assigns
.
My theory is that assigns[:email]
is nil
, and that's what we set on
window.email
.
Let's test that theory by inspecting the email
being passed to
UserSocket.connect/3
. Drop an IO.inspect/2
in our connection:
# lib/chatter_web/channels/user_socket.ex
def connect(%{"email" => email}, socket, _connect_info) do
+ IO.inspect(email, label: "email")
{:ok, assign(socket, :email, email)}
end
Now let's rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 2 files (.ex)
email: ""
email: ""
email: ""
email: ""
email: ""
email: ""
email: ""
email: ""
1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.
code: |> assert_has(message("Hi everyone", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:27: (test)
Finished in 6.4 seconds
1 test, 1 failure
Good! That confirms the theory that assigns[:email]
is not set, so
window.email
is sending an empty string when trying to connect. Remove that
IO.inspect/2
, and let's set the email in our conn.assigns
next.
Passing the email from Elixir to JavaScript
We want to put the user's email as an assign when the user is authenticated. We
can do that easily by creating a plug
in our router. Open up
ChatterWeb.Router
, and add a :put_user_email
plug at the end of the
:browser
pipeline:
# lib/chatter_web/router.ex
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Doorman.Login.Session
+ plug :put_user_email
end
+
+ defp put_user_email(conn, _) do
+ if current_user = conn.assigns[:current_user] do
+ assign(conn, :email, current_user.email)
+ else
+ conn
+ end
+ end
Since we set the current_user
for authenticated users, we can use it as a
proxy for an authentication check and as the user's email source. So
we'll set the email if we have a current_user
in our conn.assigns
.
Otherwise, we'll pass the connection struct as is.
Let's rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 2 files (.ex)
.
Finished in 2.5 seconds
1 test, 0 failures
Well done!
Before diving into refactoring, consider that our channel payload and socket connection changes likely broke our channel test. Let's fix that next.
Updating the ChatRoomChannelTest
First, run the ChatRoomChannelTest
to confirm it is failing:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)
03:55:13.222 [error] GenServer #PID<0.587.0> terminating
** (KeyError) key :email not found in: %{}
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:9: ChatterWeb.ChatRoomChannel.handle_in/3
(phoenix 1.5.4) lib/phoenix/channel/server.ex:316: Phoenix.Channel.Server.handle_info/2
(stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
(stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: "new_message", join_ref: nil, payload: %{"body" => "hello world!"}, ref: #Reference<0.1394216996.1291583491.161629>, topic: "chat_room:general"}
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
** (EXIT from #PID<0.585.0>) an exception was raised:
** (KeyError) key :email not found in: %{}
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:9: ChatterWeb.ChatRoomChannel.handle_in/3
(phoenix 1.5.4) lib/phoenix/channel/server.ex:316: Phoenix.Channel.Server.handle_info/2
(stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
(stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Finished in 0.1 seconds
1 test, 1 failure
Our channel implementation is trying to get a socket.assigns.email
, but our
test does not set it in the connection setup. Let's update the test to do so:
# test/chatter_web/channels/chat_room_channel_test.exs
describe "new_message event" do
test "broadcasts message to all users" do
- {:ok, _, socket} = join_channel("chat_room:general")
+ email = "random@example.com"
+ {:ok, _, socket} = join_channel("chat_room:general", as: email)
payload = %{"body" => "hello world!"}
push(socket, "new_message", payload)
assert_broadcast "new_message", ^payload
end
- defp join_channel(topic) do
+ defp join_channel(topic, as: email) do
ChatterWeb.UserSocket
- |> socket("", %{})
+ |> socket("", %{email: email})
|> subscribe_and_join(ChatterWeb.ChatRoomChannel, topic)
end
end
We modify our join_channel/1
helper function to take a second argument for the
email. Then, we set that email in the socket assigns when calling socket/3
.
Now let's rerun our channel test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
Assertion failed, no matching message after 100ms
The following variables were pinned:
payload = %{"body" => "hello world!"}
Showing 2 of 2 messages in the mailbox
code: assert_receive %Phoenix.Socket.Broadcast{event: "new_message", payload: ^payload}
mailbox:
pattern: %Phoenix.Socket.Broadcast{event: "new_message", payload: ^payload}
value: %Phoenix.Socket.Broadcast{
event: "new_message",
payload: %{"body" => "hello world!", "author" => "random@example.com"},
topic: "chat_room:general"
}
pattern: %Phoenix.Socket.Broadcast{event: "new_message", payload: ^payload}
value: %Phoenix.Socket.Message{
event: "new_message",
payload: %{"body" => "hello world!", "author" => "random@example.com"},
join_ref: nil,
ref: nil,
topic: "chat_room:general"
}
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:12: (test)
Finished in 0.2 seconds
1 test, 1 failure
Ah, don't you love helpful error messages? Our broadcast payload has changed, and the error message shows us the process's mailbox. We see the payload we should be expecting in the broadcast struct (which includes the author):
%Phoenix.Socket.Broadcast{
event: "new_message",
payload: %{"body" => "hello world!", "author" => "random@example.com"},
topic: "chat_room:general"
}
But why is there a second message in our mailbox?
Phoenix's assert_broadcast/2
test helper uses ExUnit's
assert_receive/3
helper which pattern matches the expected message against the messages in our
test process's mailbox. It seems that our Phoenix channel broadcasts the new
event to all subscribed clients and also pushes the event to our own client.
It's that pushed event that we see as the second message in the test process's
mailbox.
Now, let's update our assertion to include the author in the expected payload:
# test/chatter_web/channels/chat_room_channel_test.exs
test "broadcasts message to all users" do
email = "random@example.com"
{:ok, _, socket} = join_channel("chat_room:general", as: email)
payload = %{"body" => "hello world!"}
push(socket, "new_message", payload)
- assert_broadcast "new_message", ^payload
+ expected_payload = Map.put(payload, "author", email)
+ assert_broadcast "new_message", ^expected_payload
end
Now rerun our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
.
Finished in 0.04 seconds
1 test, 0 failures
Excellent! Our channel test is passing again.
Now that we have fixed our ChatRoomChannel
, let's run our test suite to make
sure we haven't broken anything unexpectedly.
$ mix test
.........................
Finished in 5.9 seconds
25 tests, 0 failures
Nothing like a screen full of green dots. Perfect! This is a great place to commit our work. We'll consider what to refactor next.
Refactoring
Most of the code we introduced is straightforward and does not need refactoring.
But there is one thing we can do: I prefer having custom plugs outside of the
Router
module to keep it focused on routes. So let's extract the
:put_user_email
function into a module plug. As usual, we'll keep a test
running as we refactor to ensure our application's behavior doesn't change.
Let's start! Run our user_can_chat
feature test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
.
Finished in 2.8 seconds
1 test, 0 failures
Open lib/chatter_web/router.ex
and copy put_user_email/2
. Now create a new
file under lib/chatter_web/plugs/
called put_user_email.ex
, and paste the
body of the function we copied into a call/2
function. We'll import
Plug.Conn
to get helpers like assign/3
, and we'll define an init/1
function since module plugs need to have one:
# lib/chatter_web/plugs/put_user_email.ex
defmodule ChatterWeb.Plugs.PutUserEmail do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
if current_user = conn.assigns[:current_user] do
assign(conn, :email, current_user.email)
else
conn
end
end
end
Now let's set that plug as part of our pipeline right after the
:put_user_email
plug:
# lib/chatter_web/router.ex
plug :put_secure_browser_headers
plug Doorman.Login.Session
plug :put_user_email
+ plug Plugs.PutUserEmail
end
And now rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 2 files (.ex)
Generated chatter app
.
Finished in 2.6 seconds
1 test, 0 failures
Great. Since our new module plug works, we can safely remove the
:put_user_email
function plug:
# lib/chatter_web/router.ex
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Doorman.Login.Session
- plug :put_user_email
plug Plugs.PutUserEmail
end
# code omitted
-
- defp put_user_email(conn, _) do
- if current_user = conn.assigns[:current_user] do
- assign(conn, :email, current_user.email)
- else
- conn
- end
- end
end
And run our test once more:
$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)
.
Finished in 2.5 seconds
1 test, 0 failures
Great! Now is a good time to commit our changes.
Wrap up
In this chapter, we saw that test-driving with JavaScript can be difficult when
errors don't directly inform us of the next step. But we also saw some
strategies to keep us moving forward: Wallaby's take_screenshot/1
and
IO.inspect/2
both unblocked us when errors were confusing. Whatever the
strategy, we aimed to confirm why an error happened and only then move
forward.
Up next, we'll add some history to our chat rooms. Currently, users only see those messages that come after they join a channel. That's not a great experience, so let's fix that in our next chapter.
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.