От переводчика: «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, и наконец ворвёмся в каналы с живой системой комментирования!

Важное заключение от переводчика


Мною была проделана огромная работа по переводу как этой статьи, так и переводу всей серии. Чем я продолжаю заниматься и сейчас. Поэтому, если вам понравилась сама статья или начинания в популяризации Эликсира в рунете, пожалуйста, поддержите статью плюсами, комментариями и репостами. Это невероятно важно как для меня лично, так и для всего сообщества Эликсира в целом.

Другие части:
  1. Вступление
  2. Авторизация
  3. Добавление ролей
  4. Скоро...

Обо всех неточностях, ошибках, плохом переводе, пожалуйста, пишите личными сообщениями, буду оперативно исправлять. Заранее благодарю всех участвующих.
Поделиться с друзьями
-->

Комментарии (11)


  1. prefrontalCortex
    08.11.2016 14:36

    Спасибо, интересно.
    А почему у вас уже вторую статью подряд в хабах фигурирует Ruby?..


    1. jarosluv
      08.11.2016 15:06

      Пожалуйста! Мне тоже серия статей кажется полезной и интересной, однако кто-то помимо минусов статье ставит ещё и минусы в карму. Хотелось бы узнать почему и что нужно улучшить, чтобы не вызывать негодование у читателей.

      По поводу хабов — я предполагаю, что Elixir может быть интересен рубистам.


      1. Fedcomp
        08.11.2016 21:02

        Статья к Ruby/Rails не относится.


        1. jarosluv
          08.11.2016 23:11

          Если отсортировать список всех хабов по релевантности, то Ruby/Rails входит в пятёрку, которую можно указать. А пока ждём создание хаба Elixir и прилагаем все возможные усилия со своей стороны к этому.


  1. Source
    09.11.2016 01:52

    Это невероятно важно как для меня лично, так и для всего сообщества Эликсира в целом.

    Я так понимаю, wunsh.ru — это Ваш личный проект… Почему Вы от лица всего сообщества Эликсира второй раз вещаете? Переводите себе спокойно статьи, к чему эти понты?


    1. jarosluv
      09.11.2016 14:36
      +1

      Потому что это уже небольшое сообщество, которое с каждым днём постепенно расширяется. Никаких понтов.


    1. jarosluv
      09.11.2016 15:54

      И я был бы крайне рад присоединению новых участников не только в качестве наблюдателей, но и в качестве информационных котрибьютеров.


      1. Source
        09.11.2016 20:02
        +1

        Так если оно и существует, то при этом оно абсолютно непрозрачное… Кто в нём участвует (подписчики не в счёт)? Какие цели сообщество ставит перед собой? Если в нём много народа, то почему полезный выхлоп — 1 перевод в месяц? Слишком много вопросов и никаких ответов Ваш сайт на них не даёт.
        Пишите в канал otp-russian.herokuapp.com, организуйте митапы, выпускайте полезные пакеты с российской тематикой и не только. Вообще Elixir сообщество вполне себе глобально и чтобы контрибютить никакие посредники не нужны. Локальные сообщества возможно имеют какую-то ценность, но так Вы непонятно даже в каком городе дислоцируетесь.


        1. jarosluv
          10.11.2016 00:32

          Благодарю за развёрнутый комментарий!

          К сожалению (и одновременно к счастью) подписчики на данный момент в счёт. В остальном пока театр одного актёра в свободное от гастролей время.

          Сейчас цель — показать русскоязычным ребятам, что Эликсир есть и его уже можно смело взять поработать. Вполне себе полноценный и интересный язык.

          Есть направление куда двигаться, приходит понимание как, появляется надежда на увеличение рабочих рук и голов. Всё, что есть сейчас, считайте прототип. Подготовка почвы под полезный контент и сервисы, в том числе и перечисленные вами.

          Если есть желание внести свой вклад, буду очень рад — пишите!


          1. Source
            10.11.2016 01:14

            В остальном пока театр одного актёра в свободное от гастролей время.

            Так я про это изначально и спросил: зачем называть себя самого Эликсир-сообществом?


            Сейчас цель — показать русскоязычным ребятам, что Эликсир есть и его уже можно смело взять поработать.

            Переводами статей? Для человека, который не знает английский на уровне "читаю без словаря" — безумие браться за Elixir. Все книги, документация — всё на английском.
            Статьи могут только привлечь внимание к технологическому стеку. Но для этого они должны быть на интересную тему… А Вы почему то выбрали цикл статей про блог… Что может быть более уныло, чем очередной мануал по написанию блога? Я прекрасно понимаю какой огромный объём работы — переводить такие длинные статьи.
            Но зачем? Во-первых, чтобы блог написать хватит и RoR, у Elixir другая область применения. Во-вторых, на хабре уже был цикл из 9 частей "Клон Trello на Phoenix и React", который освещает по большому счёту те же темы, что и ваш цикл статей.


            Если есть желание внести свой вклад, буду очень рад — пишите!

            Да я и так уже больше года вношу свой вклад в Elixir-community и не только в российское. И искренне не понимаю, что привносит Ваше начинание… Никаких возможностей оно пока не добавляет.


            1. jarosluv
              10.11.2016 11:07

              Спасибо за ваше мнение. Ещё раз повторюсь, я называю сообществом не себя, а людей, которые интересуются темой и принимают участие как минимум в качестве читателей. Это первый шаг. Посмотри, что получится дальше.