От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями.
В этой части мы доработаем основу для блога, чуть глубже погрузимся в тестирование и наконец-то добавим авторизацию. Прошу прощение за небольшую задержку, далее постараюсь придерживаться чёткого расписания, либо идти на опережение!»
На данный момент наше приложение основано на:
- Elixir: v1.3.1
- Phoenix: v1.2.0
- Ecto: v2.0.2
- Comeonin: v2.5.2
Исправляем некоторые баги
Если вы следовали первой части, у вас должен быть в некоторой степени функционирующий блоговый движок, запущенный на Elixir/Phoenix. Если вы похожи на меня, то даже такая казалось бы небольшая часть проделанной работы будоражит и заставляет скорее продвигаться дальше, вызывая желание ещё больше отполировать код.
Если вы хотите следить за ходом работы, я вылил для вас весь код в репозиторий на Github.
Первый баг довольно легко воспроизвести, перейдя по адресу http://localhost:4000/sessions/new и нажав кнопку Submit. Вы должны увидеть сообщение об ошибке, похожее на:
nil given for :username, comparison with nil is forbidden as it always evaluates to false. Pass a full query expression and use is_nil/1 instead.
Если мы взглянем на функцию create в SessionController сразу станет понятно в чём дело.
def create(conn, %{"user" => user_params}) do
user = Repo.get_by(User, username: user_params["username"])
user
|> sign_in(user_params["password"], conn)
end
Итак, если мы отправим в параметрах вместо username строку, содержащую пустое значение (или ничего), то получим ошибку. Давайте быстренько это поправим. К счастью, это делается легко с помощью охранных условий (guard clause) и сопоставления с образцом (pattern matching). Заменим текущую функцию create следующей:
def create(conn, %{"user" => %{"username" => username, "password" => password}})
when not is_nil(username) and not is_nil(password) do
user = Repo.get_by(User, username: username)
sign_in(user, password, conn)
end
def create(conn, _) do
failed_login(conn)
end
Мы заменяем аргумент params во второй функции create нижним подчёркиванием, так как нам не нужно его нигде использовать. Мы также ссылаемся на функцию failed_login, которую нужно добавить в качестве приватной. В файле web/controllers/session_controller.ex изменим импорт Comeonin:
import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
Нам нужно вызвать dummy_checkpw() так, чтобы никто не смог осуществить атаку по времени простым перебором пользователей. Далее мы добавим функцию failed_login:
defp failed_login(conn) do
dummy_checkpw()
conn
|> put_session(:current_user, nil)
|> put_flash(:error, "Invalid username/password combination!")
|> redirect(to: page_path(conn, :index))
|> halt()
end
Опять же, обратите внимание на вызов dummy_checkpw() вверху! Мы также очищаем нашу сессию current_user, устанавливаем flash-сообщение, говорящее пользователю о неправильном вводе логина и пароля и перенаправляем обратно на главную страницу. Под конец вызываем функцию halt, которая является разумной защитой от проблем двойного рендера. И затем весь аналогичный код заменяем вызовами нашей новой функции.
defp sign_in(user, _password, conn) when is_nil(user) do
failed_login(conn)
end
defp sign_in(user, password, conn) do
if checkpw(password, user.password_digest) do
conn
|> put_session(:current_user, %{id: user.id, username: user.username})
|> put_flash(:info, "Sign in successful!")
|> redirect(to: page_path(conn, :index))
else
failed_login(conn)
end
end
Эти правки должны позаботиться обо всех существующих странных багах со входом, так что мы можем двигаться дальше, чтобы связать посты с добавляющими их пользователями.
Добавим миграцию
Начнём с добавления в таблицу posts ссылки на таблицу users. Для этого через Ecto-генератор создадим миграцию:
$ mix ecto.gen.migration add_user_id_to_posts
Вывод:
Compiling 1 file (.ex)
* creating priv/repo/migrations
* creating priv/repo/migrations/20160720211140_add_user_id_to_posts.exs
Если мы откроем только что созданный файл, то ничего в нём не увидим. Так что добавим в функцию change следующий код:
def change do
alter table(:posts) do
add :user_id, references(:users)
end
create index(:posts, [:user_id])
end
Этим мы добавим колонку user_id, ссылающуюся на таблицу пользователей, а также индекс для неё. Выполним команду mix ecto.migrate
и приступим к редактированию наших моделей.
Связываем посты с пользователями
Давайте откроем файл web/models/post.ex и добавим ссылку на модель User. Внутрь схемы posts разместим строчку:
belongs_to :user, Pxblog.User
Нам нужно добавить в модель User обратную связь, указывающую обратно на модель Post. Внутрь схемы users в файле web/models/user.ex разместим строчку:
has_many :posts, Pxblog.Post
Нам также нужно открыть контроллер Posts и непосредственно связать посты с пользователями.
Изменяем пути
Начнём с обновления роутера, указав посты внутри пользователей. Для этого откроем файл web/router.ex и заменим пути /users и /posts на:
resources "/users", UserController do
resources "/posts", PostController
end
Исправляем контроллер
Если мы попробуем выполнить команду mix phoenix.routes
прямо сейчас, то получим ошибку. Это норма! Так как мы изменили структуру путей, то потеряли хелпер post_path, новая версия которого называется user_post_path и ссылается на вложенный ресурс. Вложенные хелперы позволяют нам получать доступ к путям, представленным ресурсами, которые требуют наличие другого ресурса (как например посты требуют наличие пользователя).
Итак, если у нас обычный хелпер post_path, мы вызываем его таким способом:
post_path(conn, :show, post)
Объект conn — это объект соединения, атом :show — это действие, на которое мы ссылаемся, а третий аргумент может мы либо моделью, либо идентификатором объекта. Отсюда у нас появляется возможность делать так:
post_path(conn, :show, 1)
В то же время, если у нас вложенный ресурс, хелперы изменятся вместе с изменением нашего файла routes. В нашем случае:
user_post_path(conn, :show, user, post)
Обратите внимание, что третий аргумент теперь представляет внешний ресурс, а каждый вложенный идёт следом.
Теперь, когда нам стало понятно почему возникают ошибки, мы можем их исправить. Нам нужно иметь доступ к запрашиваемому пользователю в каждом из действий контроллера. Лучший способ получить его — использовать плаг. Для этого откроем файл web/controllers/post_controller.ex и в самом верху добавим вызов нового плага:
plug :assign_user
А напишем мы его чуть ниже:
defp assign_user(conn, _opts) do
case conn.params do
%{"user_id" => user_id} ->
user = Repo.get(Pxblog.User, user_id)
assign(conn, :user, user)
_ ->
conn
end
end
И затем везде заменим post_path на user_post_path:
def create(conn, %{"post" => post_params}) do
changeset = Post.changeset(%Post{}, post_params)
case Repo.insert(changeset) do
{:ok, _post} ->
conn
|> put_flash(:info, "Post created successfully.")
|> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def update(conn, %{"id" => id, "post" => post_params}) do
post = Repo.get!(Post, id)
changeset = Post.changeset(post, post_params)
case Repo.update(changeset) do
{:ok, post} ->
conn
|> put_flash(:info, "Post updated successfully.")
|> redirect(to: user_post_path(conn, :show, conn.assigns[:user], post))
{:error, changeset} ->
render(conn, "edit.html", post: post, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
post = Repo.get!(Post, id)
# Здесь мы используем delete! (с восклицательным знаком), потому что мы ожидаем
# что код всегда будет работать (иначе возникнет ошибка).
Repo.delete!(post)
conn
|> put_flash(:info, "Post deleted successfully.")
|> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
end
Приводим в порядок шаблоны
Наш контроллер перестал «выплёвывать» сообщение об ошибке, так что теперь поработаем над нашими шаблонами. Мы пошли короткой дорогой, реализовав плаг, к которому есть доступ из любого действия контроллера. Используя функцию assign на объекте соединения, мы определяем переменную, с которой сможем работать в шаблоне. Теперь немного изменим шаблоны, заменив хелпер post_path на user_post_path и убедившись, что следующий после названия действия аргумент является идентификатором пользователя. В файле web/templates/post/index.html.eex напишем:
<h2>Listing posts</h2>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Body</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for post <- @posts do %>
<tr>
<td><%= post.title %></td>
<td><%= post.body %></td>
<td class="text-right">
<%= link "Show", to: user_post_path(@conn, :show, @user, post), class: "btn btn-default btn-xs" %>
<%= link "Edit", to: user_post_path(@conn, :edit, @user, post), class: "btn btn-default btn-xs" %>
<%= link "Delete", to: user_post_path(@conn, :delete, @user, post), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= link "New post", to: user_post_path(@conn, :new, @user) %>
В файле web/templates/post/show.html.eex:
<h2>Show post</h2>
<ul>
<li>
<strong>Title:</strong>
<%= @post.title %>
</li>
<li>
<strong>Body:</strong>
<%= @post.body %>
</li>
</ul>
<%= link "Edit", to: user_post_path(@conn, :edit, @user, @post) %>
<%= link "Back", to: user_post_path(@conn, :index, @user) %>
В файле web/templates/post/new.html.eex:
<h2>New post</h2>
<%= render "form.html", changeset: @changeset,
action: user_post_path(@conn, :create, @user) %>
<%= link "Back", to: user_post_path(@conn, :index, @user) %>
В файле web/templates/post/edit.html.eex:
<h2>Edit post</h2>
<%= render "form.html", changeset: @changeset,
action: user_post_path(@conn, :update, @user, @post) %>
<%= link "Back", to: user_post_path(@conn, :index, @user) %>
Теперь, в качестве проверки работоспособности, если мы запустим mix phoenix.routes
, мы должны увидеть вывод путей и успешную компиляцию!
Compiling 14 files (.ex)
page_path GET / Pxblog.PageController :index
user_path GET /users Pxblog.UserController :index
user_path GET /users/:id/edit Pxblog.UserController :edit
user_path GET /users/new Pxblog.UserController :new
user_path GET /users/:id Pxblog.UserController :show
user_path POST /users Pxblog.UserController :create
user_path PATCH /users/:id Pxblog.UserController :update
PUT /users/:id Pxblog.UserController :update
user_path DELETE /users/:id Pxblog.UserController :delete
user_post_path GET /users/:user_id/posts Pxblog.PostController :index
user_post_path GET /users/:user_id/posts/:id/edit Pxblog.PostController :edit
user_post_path GET /users/:user_id/posts/new Pxblog.PostController :new
user_post_path GET /users/:user_id/posts/:id Pxblog.PostController :show
user_post_path POST /users/:user_id/posts Pxblog.PostController :create
user_post_path PATCH /users/:user_id/posts/:id Pxblog.PostController :update
PUT /users/:user_id/posts/:id Pxblog.PostController :update
user_post_path DELETE /users/:user_id/posts/:id Pxblog.PostController :delete
session_path GET /sessions/new Pxblog.SessionController :new
session_path POST /sessions Pxblog.SessionController :create
session_path DELETE /sessions/:id Pxblog.SessionController :delete
Подключаем остальные части к контроллеру
Теперь, всё что нам нужно — это закончить работу над контроллером для использования новых ассоциаций. Начнём с запуска интерактивной консоли командой iex -S mix
, чтобы узнать немного о том, как выбирать посты пользователей. Но перед этим нам нужно настроить список стандартных импортов/алиасов, которые будут загружаться каждый раз при загрузке консоли iex внутри нашего проекта. Создайте в корне проекта новый файл .iex.exs (обратите внимание на точку в начале имени файла) и заполните его следующим содержимым:
import Ecto.Query
alias Pxblog.User
alias Pxblog.Post
alias Pxblog.Repo
import Ecto
Теперь, при запуске iex нам не нужно каждый раз делать ничего подобного:
iex(1)> import Ecto.Query
nil
iex(2)> alias Pxblog.User
nil
iex(3)> alias Pxblog.Post
nil
iex(4)> alias Pxblog.Repo
nil
iex(5)> import Ecto
nil
Сейчас нам нужно иметь в репозитории как минимум одного пользователя. Если это не так, то добавьте его. Затем мы можем запустить:
iex(8)> user = Repo.get(User, 1)
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [1] OK query=8.2ms
%Pxblog.User{__meta__: #Ecto.Schema.Metadata<:loaded>, email: "test", id: 1,
inserted_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, password: nil,
password_confirmation: nil,
password_digest: "$2b$12$pV/XBBCRl0RQhadQd9Y4mevOy5y0j4bCC/LjGgx7VJMosRdwme22a",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>,
updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"}
iex(10)> Repo.all(assoc(user, :posts))
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 WHERE (p0."user_id" IN ($1)) [1] OK query=3.5ms
[]
Пока у нас не создано ни одного поста для этого пользователя, так что логично получить здесь пустой список. Мы использовали функцию assoc из Ecto, чтобы получить запрос, связывающий посты с пользователем. Мы также можем сделать следующее:
iex(14)> Repo.all from p in Post,
...(14)> join: u in assoc(p, :user),
...(14)> select: p
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 INNER JOIN "users" AS u1 ON u1."id" = p0."user_id" [] OK query=0.9ms
Здесь создаётся запрос с inner join вместо прямого условия на выборку по идентификатору пользователя. Обратите особое внимание на то, как выглядят запросы, генерируемые в обоих случаях. Очень полезно понимать SQL, создаваемый «за кулисами» всегда, когда вы работаете с кодом, генерирующим запросы.
Мы также можем использовать функцию preload при выборке постов, чтобы предзагрузить также и пользователей, как показано ниже:
iex(18)> Repo.all(from u in User, preload: [:posts])
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 [] OK query=0.9ms
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 WHERE (p0."user_id" IN ($1)) ORDER BY p0."user_id" [1] OK query=0.8ms
iex(20)> Repo.all(from p in Post, preload: [:user])
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 [] OK query=0.8ms
[]
Нам нужно добавить постов, чтобы появилась возможность повозиться с запросами. Итак, для этого мы собираемся использовать функцию Ecto под названием build_assoc. Данная функция принимает первым аргументом модель, для которой мы хотим добавить ассоциацию, и вторым — саму ассоциацию в виде атома.
iex(1)> user = Repo.get(User, 1)
iex(2)> post = build_assoc(user, :posts, %{title: "Test Title", body: "Test Body"})
iex(3)> Repo.insert(post)
iex(4)> posts = Repo.all(from p in Post, preload: [:user])
И теперь, выполнив последний запрос, мы должны получить следующий вывод:
iex(4)> posts = Repo.all(from p in Post, preload: [:user])
[debug] SELECT p0."id", p0."title", p0."body", p0."user_id", p0."inserted_at", p0."updated_at" FROM "posts" AS p0 [] OK query=0.7ms
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" IN ($1)) [1] OK query=0.7ms
[%Pxblog.Post{__meta__: #Ecto.Schema.Metadata<:loaded>, body: "Test Body",
id: 1, inserted_at: #Ecto.DateTime<2015-10-06T18:06:20Z>, title: "Test Title",
updated_at: #Ecto.DateTime<2015-10-06T18:06:20Z>,
user: %Pxblog.User{__meta__: #Ecto.Schema.Metadata<:loaded>, email: "test",
id: 1, inserted_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, password: nil,
password_confirmation: nil,
password_digest: "$2b$12$pV/XBBCRl0RQhadQd9Y4mevOy5y0j4bCC/LjGgx7VJMosRdwme22a",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>,
updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"},
user_id: 1}]
И мы просто быстро проверим первый результат:
iex(5)> post = List.first posts
%Pxblog.Post{__meta__: #Ecto.Schema.Metadata<:loaded>, body: "Test Body", id: 1,
inserted_at: #Ecto.DateTime<2015-10-06T18:06:20Z>, title: "Test Title",
updated_at: #Ecto.DateTime<2015-10-06T18:06:20Z>,
user: %Pxblog.User{__meta__: #Ecto.Schema.Metadata<:loaded>, email: "test",
id: 1, inserted_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, password: nil,
password_confirmation: nil,
password_digest: "$2b$12$pV/XBBCRl0RQhadQd9Y4mevOy5y0j4bCC/LjGgx7VJMosRdwme22a",
posts: #Ecto.Association.NotLoaded<association :posts is not loaded>,
updated_at: #Ecto.DateTime<2015-10-06T17:47:07Z>, username: "test"},
user_id: 1}
iex(6)> post.title
"Test Title"
iex(7)> post.user.username
"test"
Круто! Наш эксперимент показал ровно то, что мы ожидали, так что вернёмся обратно к контроллеру (файл web/controllers/post_controller.ex) и начнём править код. В действии index мы хотим получать все посты, связанные с пользователем. С него и начнём:
def index(conn, _params) do
posts = Repo.all(assoc(conn.assigns[:user], :posts))
render(conn, "index.html", posts: posts)
end
Теперь мы можем сходить посмотреть список постов для первого пользователя! Но если мы попробуем получить список постов для пользователя, которого не существует, мы получим сообщение об ошибке, что является плохим UX, так что давайте приведём в порядок наш плаг assign_user:
defp assign_user(conn, _opts) do
case conn.params do
%{"user_id" => user_id} ->
case Repo.get(Pxblog.User, user_id) do
nil -> invalid_user(conn)
user -> assign(conn, :user, user)
end
_ -> invalid_user(conn)
end
end
defp invalid_user(conn) do
conn
|> put_flash(:error, "Invalid user!")
|> redirect(to: page_path(conn, :index))
|> halt
end
Теперь, когда мы откроем список постов для несуществующего пользователя, мы получим милое flash-сообщение и будем любезно переадресованы на page_path. Далее нам нужно изменить действие new:
def new(conn, _params) do
changeset =
conn.assigns[:user]
|> build_assoc(:posts)
|> Post.changeset()
render(conn, "new.html", changeset: changeset)
end
Мы берём модель user, передаём её в функцию build_assoc, говоря что нам нужно создать пост, и затем передаём получившуюся пустую модель в функцию Post.changeset, чтобы получить пустую ревизию. Мы пойдём тем же путём для метода create (за исключением добавления post_params):
def create(conn, %{"post" => post_params}) do
changeset =
conn.assigns[:user]
|> build_assoc(:posts)
|> Post.changeset(post_params)
case Repo.insert(changeset) do
{:ok, _post} ->
conn
|> put_flash(:info, "Post created successfully.")
|> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
И затем изменим действия show, edit, update и delete:
def show(conn, %{"id" => id}) do
post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
render(conn, "show.html", post: post)
end
def edit(conn, %{"id" => id}) do
post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
changeset = Post.changeset(post)
render(conn, "edit.html", post: post, changeset: changeset)
end
def update(conn, %{"id" => id, "post" => post_params}) do
post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
changeset = Post.changeset(post, post_params)
case Repo.update(changeset) do
{:ok, post} ->
conn
|> put_flash(:info, "Post updated successfully.")
|> redirect(to: user_post_path(conn, :show, conn.assigns[:user], post))
{:error, changeset} ->
render(conn, "edit.html", post: post, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
post = Repo.get!(assoc(conn.assigns[:user], :posts), id)
# Здесь мы используем delete! (с восклицательным знаком), потому что мы ожидаем
# что оно всегда будет работать (иначе возникнет ошибка).
Repo.delete!(post)
conn
|> put_flash(:info, "Post deleted successfully.")
|> redirect(to: user_post_path(conn, :index, conn.assigns[:user]))
end
После прогона всех тестов мы должны увидеть, что всё работает. За исключением того, что… любой пользователь имеет возможность удалить/отредактировать/создать новый пост под любым юзером, каким захочет!
Ограничиваем создание постов пользователями
Мы не можем выпустить блоговый движок с такой дырой в безопасности. Давайте исправим это, добавив ещё один плаг, который гарантирует, что полученный пользователь является также текущим пользователем.
Добавим новую функцию в конец файла web/controllers/post_controller.ex:
defp authorize_user(conn, _opts) do
user = get_session(conn, :current_user)
if user && Integer.to_string(user.id) == conn.params["user_id"] do
conn
else
conn
|> put_flash(:error, "You are not authorized to modify that post!")
|> redirect(to: page_path(conn, :index))
|> halt()
end
end
А в самом верху добавим вызов плага:
plug :authorize_user when action in [:new, :create, :update, :edit, :delete]
Теперь всё должно прекрасно работать! Пользователи должны быть зарегистрированы, чтобы оставлять посты, а затем работать только с ними. Всё, что нам осталось — обновить набор тестов для обработки этих изменений, и всё будет готово. Для начала просто запустим mix test, чтобы оценить текущую ситуацию. Скорее всего вы увидите такую ошибку:
** (CompileError) test/controllers/post_controller_test.exs:14: function post_path/2 undefined
(stdlib) lists.erl:1337: :lists.foreach/2
(stdlib) erl_eval.erl:669: :erl_eval.do_apply/6
(elixir) lib/code.ex:363: Code.require_file/2
(elixir) lib/kernel/parallel_require.ex:50: anonymous fn/4 in Kernel.ParallelRequire.spawn_requires/5
К сожалению, нам нужно изменить каждый вызов post_path на user_post_path снова. И для того, чтобы сделать это, нам нужно радикально изменить наши тесты. Начнём с добавления блока настроек в файл test/controllers/post_controller_text.exs:
alias Pxblog.User
setup do
{:ok, user} = create_user
conn = build_conn()
|> login_user(user)
{:ok, conn: conn, user: user}
end
defp create_user do
User.changeset(%User{}, %{email: "test@test.com", username: "test", password: "test", password_confirmation: "test"})
|> Repo.insert
end
defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end
Здесь происходит много всего. Первое, что мы сделали — добавили вызов функции create_user, которую нам нужно написать. Нам нужно несколько хелперов для тестов, так что давайте их добавим. Функция create_user просто добавляет тестового пользователя в Repo, именно поэтому мы используем сопоставление с образцом {:ok, user} при вызове этой функции.
Далее мы вызываем conn = build_conn(), также как и ранее. Далее передаём результат conn в функцию login_user. Это соединяет посты с нашей функцией входа, т. к. все основные действия с постами требуют наличие пользователя. Очень важно понять, что нам необходимо возвращать conn и таскать его с собой в каждый отдельный тест. Если мы не сделаем это, то пользователь не будет оставаться вошедшим в систему.
Наконец, мы изменили возврат той функции на возврат стандартных значений :ok и :conn, но теперь мы также включим ещё одну запись :user в словарь. Давайте взглянем на первый тест, который изменим:
test "lists all entries on index", %{conn: conn, user: user} do
conn = get conn, user_post_path(conn, :index, user)
assert html_response(conn, 200) =~ "Listing posts"
end
Обратите внимание, мы изменили второй аргумент метода test, чтобы при помощи сопоставления с образцом получать словарь, содержащий помимо ключа :conn, также и ключ :user. Это гарантирует, что мы используем ключ :user, с которым мы работаем в блоке setup. Помимо этого мы изменили вызов хелпера post_path на user_post_path и добавили пользователя третьим аргументом. Запустим сейчас только непосредственно этот тест. Это можно сделать с помощью указания тега, либо указав номер нужной строчки выполнив команду таким образом:
$ mix test test/controller/post_controller_test.exs:[line number]
Наш тест должен позеленеть! Великолепно! Теперь давайте изменим этот кусок:
test "renders form for new resources", %{conn: conn, user: user} do
conn = get conn, user_post_path(conn, :new, user)
assert html_response(conn, 200) =~ "New post"
end
Здесь ничего нового, кроме изменения обработчика setup и пути, так что идём дальше.
test "creates resource and redirects when data is valid", %{conn: conn, user: user} do
conn = post conn, user_post_path(conn, :create, user), post: @valid_attrs
assert redirected_to(conn) == user_post_path(conn, :index, user)
assert Repo.get_by(assoc(user, :posts), @valid_attrs)
end
Не забывайте, что мы должны были получать каждый пост, связанный с пользователем, так что изменим все вызовы post_path.
test "does not create resource and renders errors when data is invalid", %{conn: conn, user: user} do
conn = post conn, user_post_path(conn, :create, user), post: @invalid_attrs
assert html_response(conn, 200) =~ "New post"
end
Другой слегка измененный тест. Смотреть нечего, так что давайте перейдём к следующему более интересному. Вспомним снова, что мы создаём/получаем посты, принадлежащие ассоциации пользователей, так что переходим к изменению теста “shows chosen resource”:
test "shows chosen resource", %{conn: conn, user: user} do
post = build_post(user)
conn = get conn, user_post_path(conn, :show, user, post)
assert html_response(conn, 200) =~ "Show post"
end
Ранее мы добавляли посты с помощью простого Repo.insert! %Post{}
. Это больше не будет работать, так что теперь нам нужно создавать их с правильной ассоциацией. Так как эта строчка используется довольно часто в оставшихся тестах, мы напишем хелпер, чтобы облегчить её использование.
defp build_post(user) do
changeset =
user
|> build_assoc(:posts)
|> Post.changeset(@valid_attrs)
Repo.insert!(changeset)
end
Данный метод создаёт валидную модель поста, связанную с пользователем, и затем вставляет её в базу данных. Обратите внимание, что Repo.insert! возвращает не {:ok, model}, а возвращает саму модель!
Вернёмся к нашему тесту, который мы изменяли. Я хочу выложить остаток тестов, а вы просто повторите соответствующие изменения одно за другим, пока все тесты не начнут проходить.
test "renders page not found when id is nonexistent", %{conn: conn, user: user} do
assert_raise Ecto.NoResultsError, fn ->
get conn, user_post_path(conn, :show, user, -1)
end
end
test "renders form for editing chosen resource", %{conn: conn, user: user} do
post = build_post(user)
conn = get conn, user_post_path(conn, :edit, user, post)
assert html_response(conn, 200) =~ "Edit post"
end
test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user} do
post = build_post(user)
conn = put conn, user_post_path(conn, :update, user, post), post: @valid_attrs
assert redirected_to(conn) == user_post_path(conn, :show, user, post)
assert Repo.get_by(Post, @valid_attrs)
end
test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user} do
post = build_post(user)
conn = put conn, user_post_path(conn, :update, user, post), post: %{"body" => nil}
assert html_response(conn, 200) =~ "Edit post"
end
test "deletes chosen resource", %{conn: conn, user: user} do
post = build_post(user)
conn = delete conn, user_post_path(conn, :delete, user, post)
assert redirected_to(conn) == user_post_path(conn, :index, user)
refute Repo.get(Post, post.id)
end
Когда вы поправите их все, то сможете запустить команду mix test и получить зелёные тесты!
Наконец, мы написали немного нового кода, такого как плаги для обработки поиска пользователей и авторизации, и мы протестиовали успешные случаи довольно хорошо, но нам нужно добавить тесты и на негативные случаи. Мы начнём с теста на то, что произойдёт, когда мы попробуем получить доступ к постам пользователя, который не существует.
test "redirects when the specified user does not exist", %{conn: conn} do
conn = get conn, user_post_path(conn, :index, -1)
assert get_flash(conn, :error) == "Invalid user!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end
Мы не включили :user в сопоставление с образцом из блока setup, т. к. здесь его не используем. Также проверяем, что соединение закрывается в конце.
И наконец, нам нужно написать тест, в котором мы попробуем отредактировать чужой пост.
test "redirects when trying to edit a post for a different user", %{conn: conn, user: user} do
other_user = User.changeset(%User{}, %{email: "test2@test.com", username: "test2", password: "test", password_confirmation: "test"})
|> Repo.insert!
post = build_post(user)
conn = get conn, user_post_path(conn, :edit, other_user, post)
assert get_flash(conn, :error) == "You are not authorized to modify that post!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end
Мы создаём другого пользователя, который станет нашим плохим пользователем, и добавляем его в Repo. Затем мы пробуем получить доступ к действию edit для поста нашего первого пользователя. Это заставит сработать негативный случай нашего плага authorize_user! Сохраните файл, запустите команду mix test
и дождитесь результатов:
.......................................
Finished in 0.4 seconds
39 tests, 0 failures
Randomized with seed 102543
Вот так-то! Проделали немало! Зато теперь у нас есть функциональный (и более защищённый блог) с постами, создающимися под пользователями. И у нас по-прежнему хорошее покрытие тестами! Пришло время передохнуть. Мы продолжим эту серию обучающих материалов добавлением роли администратора, комментариев, поддержки Markdown, и наконец ворвёмся в каналы с живой системой комментирования!
Важное заключение от переводчика
Мною была проделана огромная работа по переводу как этой статьи, так и переводу всей серии. Чем я продолжаю заниматься и сейчас. Поэтому, если вам понравилась сама статья или начинания в популяризации Эликсира в рунете, пожалуйста, поддержите статью плюсами, комментариями и репостами. Это невероятно важно как для меня лично, так и для всего сообщества Эликсира в целом.
Обо всех неточностях, ошибках, плохом переводе, пожалуйста, пишите личными сообщениями, буду оперативно исправлять. Заранее благодарю всех участвующих.
Автор: jarosluv