Phoenix Recipe 控制器

2023-12-18 15:00 更新

上一章結(jié)尾,我們運行 mix test 檢查出 recipe_controller_test.exs 文件中的兩個錯誤。

很顯然,它們是因為 @valid_attrs 中缺少 user_id 導致的。

怎么辦,在 @valid_attrs 中隨意添加個 user_id 可不能解決問題 - 用戶必須存在。

一個粗暴的解決辦法,是在每個測試中新建一個用戶,然后把用戶 id 傳給 @valid_attrs,但那樣又要重復一堆代碼,我們可以把新建用戶部分抽取到 setup 中:

diff --git a/test/tv_recipe_web/controllers/recipe_controller_test.exs b/test/tv_recipe_web/controllers/recipe_controller_test.exs
index 923a4a9..0548c85 100644
--- a/test/tv_recipe_web/controllers/recipe_controller_test.exs
+++ b/test/tv_recipe_web/controllers/recipe_controller_test.exs
@@ -2,17 +2,29 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   use TvRecipeWeb.ConnCase
 
   alias TvRecipe.Recipes
+  alias TvRecipe.Repo
+  alias TvRecipe.Users.User
+  alias TvRecipe.Recipes.Recipe
 
   @create_attrs %{content: "some content", episode: 42, name: "some name", season: 42, title: "some title"}
   @update_attrs %{content: "some updated content", episode: 43, name: "some updated name", season: 43, title: "some updated title"}
   @invalid_attrs %{content: nil, episode: nil, name: nil, season: nil, title: nil}
 
+  defp init_attrs (%{conn: conn} = context) do
+     user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)})
+     attrs = Map.put(@create_attrs, :user_id, user.id)
+
+     context
+     |> Map.put(:attrs, attrs)
+  end
+
   def fixture(attrs) do
     {:ok, recipe} = Recipes.create_recipe(attrs)
     recipe
   end
 
   describe "index" do
+    setup [:init_attrs]
     test "lists all recipes", %{conn: conn} do
       conn = get(conn, Routes.recipe_path(conn, :index))
       assert html_response(conn, 200) =~ "Listing Recipes"
@@ -27,8 +39,9 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "create recipe" do
+    setup [:init_attrs]
-    test "redirects to show when data is valid", %{conn: conn} do
+    test "redirects to show when data is valid", %{conn: conn, attrs: attrs} do
-      conn = post(conn, Routes.recipe_path(conn, :create), recipe: @create_attrs)
+      conn = post(conn, Routes.recipe_path(conn, :create), recipe: attrs)
 
       assert %{id: id} = redirected_params(conn)
       assert redirected_to(conn) == Routes.recipe_path(conn, :show, id)
@@ -44,7 +57,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "edit recipe" do
-    setup [:create_recipe]
+    setup [:init_attrs, :create_recipe]
 
     test "renders form for editing chosen recipe", %{conn: conn, recipe: recipe} do
       conn = get(conn, Routes.recipe_path(conn, :edit, recipe))
@@ -53,7 +66,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "update recipe" do
-    setup [:create_recipe]
+    setup [:init_attrs, :create_recipe]
 
     test "redirects when data is valid", %{conn: conn, recipe: recipe} do
       conn = put(conn, Routes.recipe_path(conn, :update, recipe), recipe: @update_attrs)
@@ -70,7 +83,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "delete recipe" do
-    setup [:create_recipe]
+    setup [:init_attrs, :create_recipe]
 
     test "deletes chosen recipe", %{conn: conn, recipe: recipe} do
       conn = delete(conn, Routes.recipe_path(conn, :delete, recipe))
@@ -81,8 +94,10 @@ defmodule TvRecipeWeb.RecipeControllerTest do
     end
   end
 
-  defp create_recipe(_) do
+  defp create_recipe(%{attrs: attrs} = context) do
-    recipe = fixture(:recipe)
+    recipe = fixture(attrs)
+
-    %{recipe: recipe}
+    context
+    |> Map.put(:recipe, recipe)
   end
 end

setup 塊中,我們新建了一個用戶,并且重新組合出真正有效的 recipe 屬性 attrs,然后返回。

現(xiàn)在運行測試:

$ mix test
......................................................

Finished in 0.8 seconds
56 tests, 0 failures

非常好,全部通過了。

接下來,我們處理動作的權(quán)限問題。

Recipe 動作的權(quán)限

我們先確認 RecipeController 模塊中各個動作的權(quán)限要求:

動作名 是否需要登錄
index 需要
new 需要
create 需要
show 需要
edit 需要
update 需要
delete 需要

都要登錄?難道未登錄用戶不能查看其它用戶創(chuàng)建的菜譜?當然可以,但我們將新建路由來滿足這些需求。這一節(jié),我們開發(fā)的是 Recipe 相關(guān)的管理動作。

前面章節(jié)中我們已經(jīng)嘗試過使用 setup [:login_user] 來標注用戶登錄狀態(tài)下的測試,現(xiàn)在根據(jù)上面羅列的需求來修改 recipe_controller_test.exs 文件中的測試:

diff --git a/test/tv_recipe_web/controllers/recipe_controller_test.exs b/test/tv_recipe_web/controllers/recipe_controller_test.exs
index 0548c85..5eb8866 100644
--- a/test/tv_recipe_web/controllers/recipe_controller_test.exs
+++ b/test/tv_recipe_web/controllers/recipe_controller_test.exs
@@ -10,8 +10,18 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   @update_attrs %{content: "some updated content", episode: 43, name: "some updated name", season: 43, title: "some updated title"}
   @invalid_attrs %{content: nil, episode: nil, name: nil, season: nil, title: nil}
 
+  defp login_user(%{conn: conn} = context) do
+    user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}
+    user = Repo.insert! User.changeset(%User{}, user_attrs)
+    attrs = Map.put(@create_attrs, :user_id, user.id)
+    conn = post conn, Routes.session_path(conn, :create), session: user_attrs
+
+    context
+    |> Map.put(:conn, conn)
+    |> Map.put(:user, user)
+  end
+
-  defp init_attrs (%{conn: conn} = context) do
+  defp init_attrs (%{user: user} = context) do
-     user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)})
      attrs = Map.put(@create_attrs, :user_id, user.id)
 
      context
@@ -24,7 +34,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "index" do
-    setup [:init_attrs]
+    setup [:login_user, :init_attrs]
     test "lists all recipes", %{conn: conn} do
       conn = get(conn, Routes.recipe_path(conn, :index))
       assert html_response(conn, 200) =~ "Listing Recipes"
@@ -39,7 +49,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "create recipe" do
-    setup [:init_attrs]
+    setup [:login_user, :init_attrs]
     test "redirects to show when data is valid", %{conn: conn, attrs: attrs} do
       conn = post(conn, Routes.recipe_path(conn, :create), recipe: attrs)
 
@@ -57,7 +67,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "edit recipe" do
-    setup [:init_attrs, :create_recipe]
+    setup [:login_user, :init_attrs, :create_recipe]
 
     test "renders form for editing chosen recipe", %{conn: conn, recipe: recipe} do
       conn = get(conn, Routes.recipe_path(conn, :edit, recipe))
@@ -66,7 +76,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "update recipe" do
-    setup [:init_attrs, :create_recipe]
+    setup [:login_user, :init_attrs, :create_recipe]
 
     test "redirects when data is valid", %{conn: conn, recipe: recipe} do
       conn = put(conn, Routes.recipe_path(conn, :update, recipe), recipe: @update_attrs)
@@ -83,7 +93,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end
 
   describe "delete recipe" do
-    setup [:init_attrs, :create_recipe]
+    setup [:login_user, :init_attrs, :create_recipe]
 
     test "deletes chosen recipe", %{conn: conn, recipe: recipe} do
       conn = delete(conn, Routes.recipe_path(conn, :delete, recipe))

我們給所有測試代碼都加上了 setup [:login_user, :init_attrs] 的設置。

接下來我們需要一個驗證用戶登錄狀態(tài)的 plug,不巧我們在 user_controller.ex 文件中已經(jīng)定義了一個 login_require 的 plug,現(xiàn)在是其它地方也要用到它 - 再放在 user_controller.ex 中并不合適,我們將它移到 auth.ex 文件中:

diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex
index e298b68..3dd3e7f 100644
--- a/web/controllers/auth.ex
+++ b/web/controllers/auth.ex
@@ -1,5 +1,7 @@
 defmodule TvRecipeWeb.Auth do
   import Plug.Conn
+  import Phoenix.Controller
+  use TvRecipeWeb, :controller

   @doc """
   初始化選項
@@ -21,4 +23,37 @@ defmodule TvRecipeWeb.Auth do
     |> configure_session(renew: true)
   end

+  @doc """
+  檢查用戶登錄狀態(tài)
+
+  Returns `conn`
+  """
+  def login_require(conn, _opts) do
+    if conn.assigns.current_user do
+      conn
+    else
+      conn
+      |> put_flash(:info, "請先登錄")
+      |> redirect(to: Routes.session_path(conn, :new))
+      |> halt()
+    end
+  end
+
+  @doc """
+  檢查用戶是否授權(quán)訪問動作
+
+  Returns `conn`
+  """
+  def self_require(conn, _opts) do
+    %{"id" => id} = conn.params
+    if String.to_integer(id) == conn.assigns.current_user.id do
+      conn
+    else
+      conn
+      |> put_flash(:error, "禁止訪問未授權(quán)頁面")
+      |> redirect(to: Routes.user_path(conn, :show, conn.assigns.current_user))
+      |> halt()
+    end
+  end
+
 end
\ No newline at end of file
diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
index 520d986..0f023d3 100644
--- a/web/controllers/user_controller.ex
+++ b/web/controllers/user_controller.ex
@@ -49,36 +49,4 @@ defmodule TvRecipe.UserController do
     end
   end

-  @doc """
-  檢查用戶登錄狀態(tài)
-
-  Returns `conn`
-  """
-  def login_require(conn, _opts) do
-    if conn.assigns.current_user do
-      conn
-    else
-      conn
-      |> put_flash(:info, "請先登錄")
-      |> redirect(to: Routes.session_path(conn, :new))
-      |> halt()
-    end
-  end
-
-  @doc """
   end

-  @doc """
-  檢查用戶登錄狀態(tài)
-
-  Returns `conn`
-  """
-  def login_require(conn, _opts) do
-    if conn.assigns.current_user do
-      conn
-    else
-      conn
-      |> put_flash(:info, "請先登錄")
-      |> redirect(to: Routes.session_path(conn, :new))
-      |> halt()
-    end
-  end
-
-  @doc """
-  檢查用戶是否授權(quán)訪問動作
-
-  Returns `conn`
-  """
-  def self_require(conn, _opts) do
-    %{"id" => id} = conn.params
-    if String.to_integer(id) == conn.assigns.current_user.id do
-      conn
-    else
-      conn
-      |> put_flash(:error, "禁止訪問未授權(quán)頁面")
-      |> redirect(to: Routes.user_path(conn, :show, conn.assigns.current_user))
-      |> halt()
-    end
-  end
 end

注意,我們并非只是簡單的移動文本到 auth.ex 文件中。在 auth.ex 頭部,我們還引入了兩行代碼,并調(diào)整了兩個 plug:

  import Phoenix.Controller
  use TvRecipeWeb, :controller

import Phoenix.Controller 導入 put_flash 等方法,而 use TvRecipeWeb, :controller 讓我們在 auth.ex 中可以快速書寫各種路徑。

接著在 user_controller.ex 文件中 import 它:

diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex
index 50fd62e..9990080 100644
--- a/web/web.ex
+++ b/web/web.ex
@@ -36,6 +36,7 @@ defmodule TvRecipe.Web do
+      import TvRecipeWeb.Auth, only: [login_require: 2, self_require: 2]
     end
   end

注意,目前我們只在 controllerimport,后面可能會需要在 routerimport。

最后,將 plug 應用到 recipe_controller.ex 文件中:

diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
index 96a0276..c74b492 100644
--- a/web/controllers/recipe_controller.ex
+++ b/web/controllers/recipe_controller.ex
@@ -1,6 +1,6 @@
 defmodule TvRecipeWeb.RecipeController do
   use TvRecipeWeb, :controller
   import TvRecipeWeb.Auth, only: [login_require: 2]
-
+  plug :login_require
   alias TvRecipe.Recipe

   def index(conn, _params) do

我們這次并沒有使用 when action in,plug 將應用到該文件中所有的動作。

運行測試:

$ mix test
......................................................

Finished in 0.7 seconds
56 tests, 0 failures

一切順利。

但因為我們現(xiàn)在所有測試都針對登錄狀態(tài),我們還需要補充下未登錄狀態(tài)的測試:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index 5632f8c..faf67ca 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -16,7 +16,7 @@ defmodule TvRecipeWeb.RecipeControllerTest do
       {:ok, [attrs: attrs]}
     end
   end
-
+
   @tag logged_in: true
   test "lists all entries on index", %{conn: conn} do
     conn = get conn, Routes.recipe_path(conn, :index)
@@ -85,4 +85,20 @@ defmodule TvRecipeWeb.RecipeControllerTest do
     assert redirected_to(conn) == Routes.recipe_path(conn, :index)
     refute Repo.get(Recipe, recipe.id)
   end
+
+  test "guest access user action redirected to login page", %{conn: conn} do
+    recipe = Repo.insert! %Recipe{}
+    Enum.each([
+      get(conn, Routes.recipe_path(conn, :index)),
+      get(conn, Routes.recipe_path(conn, :new)),
+      get(conn, Routes.recipe_path(conn, :create), recipe: %{}),
+      get(conn, Routes.recipe_path(conn, :show, recipe)),
+      get(conn, Routes.recipe_path(conn, :edit, recipe)),
+      put(conn, Routes.recipe_path(conn, :update, recipe), recipe: %{}),
+      put(conn, Routes.recipe_path(conn, :delete, recipe)),
+    ], fn conn ->
+      assert redirected_to(conn) == Routes.session_path(conn, :new)
+      assert conn.halted
+    end)
+  end
 end

user_id 與 :current_user

在前面的測試里,我們先創(chuàng)建一個新用戶,然后登錄新用戶,新建 recipe,并將新用戶的 id 與新建的 recipe 關(guān)聯(lián)起來。

考慮另一種情況:

  1. 新建用戶 A
  2. 新建用戶 B
  3. 登錄用戶 B
  4. 關(guān)聯(lián)用戶 A 的 id 與 Recipe

這一種情況,我們的測試還沒有覆蓋到。

我們來新增一個測試,驗證一下:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index faf67ca..d8157a2 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -101,4 +101,17 @@ defmodule TvRecipeWeb.RecipeControllerTest do
       assert conn.halted
     end)
   end
+
+  test "creates resource and redirects when data is valid but with other user_id", %{conn: conn, attrs: attrs} do
+    # 新建一個用戶
+    user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan+1@gmail.com", username: "samchen", password: String.duplicate("1", 6)})
+    # 將新用戶的 id 更新入 attrs,嘗試替 samchen 創(chuàng)建一個菜譜
+    new_attrs = %{attrs | user_id: user.id}
+    post conn, Routes.recipe_path(conn, :create), recipe: new_attrs
+    # 用戶 chenxsan 只能創(chuàng)建自己的菜譜,無法替 samchen 創(chuàng)建菜譜
+    assert Repo.get_by(Recipe, attrs)
+    # samchen 不應該有菜譜
+    refute Repo.get_by(Recipe, new_attrs)
+  end
 end

運行測試:

$ mix test
..........................

  1) test creates resource and redirects when data is valid but with other user_id (TvRecipeWeb.RecipeControllerTest)
     test/controllers/recipe_controller_test.exs:106
     Expected truthy, got nil
     code: Repo.get_by(Recipe, attrs)
     stacktrace:
       test/controllers/recipe_controller_test.exs:113: (test)

.............................

Finished in 0.7 seconds
58 tests, 1 failure

測試失?。旱卿洜顟B(tài)下的用戶 A 給用戶 B 創(chuàng)建了一個菜譜。

那么要如何修改我們的控制器代碼?

我們可以像測試中一樣,把登錄用戶的 id 傳遞進去,比如:

def create(conn, %{"recipe" => recipe_params}) do
  changeset = Recipe.changeset(%Recipe{}, Map.put(recipe_params, "user_id", conn.assigns.current_user.id))

可是,我們?yōu)槭裁匆峁┙o用戶傳遞 user_id 的機會呢?

讓我們從 recipe.ex 文件中把 user_id 相關(guān)的代碼去掉:

diff --git a/web/models/recipe.ex b/web/models/recipe.ex
index a0b42fd..8d34ed2 100644
--- a/web/models/recipe.ex
+++ b/web/models/recipe.ex
@@ -17,8 +17,7 @@ defmodule TvRecipe.Recipe do
   """
   def changeset(struct, params \\ %{}) do
     struct
-    |> cast(params, [:name, :title, :season, :episode, :content, :user_id])
-    |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "請?zhí)顚?)
-    |> foreign_key_constraint(:user_id, message: "用戶不存在")
+    |> cast(params, [:name, :title, :season, :episode, :content])
+    |> validate_required([:name, :title, :season, :episode, :content], message: "請?zhí)顚?)
   end
 end

recipe_test.exsuser_id 相關(guān)的代碼也要去掉:

diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
index 2e1191c..4e59fb9 100644
--- a/test/models/recipe_test.exs
+++ b/test/models/recipe_test.exs
@@ -3,7 +3,7 @@ defmodule TvRecipe.RecipeTest do

   alias TvRecipe.{Recipe}

-  @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1}
+  @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
   @invalid_attrs %{}

   test "changeset with valid attributes" do
@@ -40,14 +40,5 @@ defmodule TvRecipe.RecipeTest do
     attrs = %{@valid_attrs | content: ""}
     assert {:content, "請?zhí)顚?} in errors_on(%Recipe{}, attrs)
   end
-
-  test "user_id is required" do
-    attrs = %{@valid_attrs | user_id: nil}
-    assert {:user_id, "請?zhí)顚?} in errors_on(%Recipe{}, attrs)
-  end

-  test "user_id should exist in users table" do
-    {:error, changeset} = Repo.insert Recipe.changeset(%Recipe{}, @valid_attrs)
-    assert {:user_id, "用戶不存在"} in errors_on(changeset)
-  end
 end

不要忘了還有 recipe_controller_test.exs 文件中的代碼:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index d8157a2..d953315 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -7,13 +7,12 @@ defmodule TvRecipeWeb.RecipeControllerTest do

-  defp init_attrs(%{user: user} = context) do
+  defp init_attrs(%{conn: conn} = context) do
-     attrs = Map.put(@valid_attrs, :user_id, user.id)
-
      context
      |> Map.put :attrs, @@valid_attrs
   end

@@ -30,10 +29,10 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end

-  test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
-    conn = post conn, Routes.recipe_path(conn, :create), recipe: attrs
+  test "creates resource and redirects when data is valid", %{conn: conn} do
+    conn = post conn, Routes.recipe_path(conn, :create), recipe: @valid_attrs
     assert redirected_to(conn) == Routes.recipe_path(conn, :index)
-    assert Repo.get_by(Recipe, attrs)
+    assert Repo.get_by(Recipe, @valid_attrs)
   end

@@ -64,11 +63,11 @@ defmodule TvRecipeWeb.RecipeControllerTest do
@@ -64,11 +63,11 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end

-  test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do
+  test "updates chosen resource and redirects when data is valid", %{conn: conn} do
     recipe = Repo.insert! %Recipe{}
-    conn = put conn, Routes.recipe_path(conn, :update, recipe), recipe: attrs
+    conn = put conn, Routes.recipe_path(conn, :update, recipe), recipe: @valid_attrs
     assert redirected_to(conn) == Routes.recipe_path(conn, :show, recipe)
-    assert Repo.get_by(Recipe, attrs)
+    assert Repo.get_by(Recipe, @valid_attrs)
   end


@@ -102,16 +101,4 @@ defmodule TvRecipeWeb.RecipeControllerTest do
     end)
   end

-  @tag logged_in: true
-  test "creates resource and redirects when data is valid but with other user_id", %{conn: conn, attrs: attrs} do
-    # 新建一個用戶
-    user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan+1@gmail.com", username: "samchen", password: String.duplicate("1", 6)})
-    # 將新用戶的 id 更新入 attrs,嘗試替 samchen 創(chuàng)建一個菜譜
-    new_attrs = %{attrs | user_id: user.id}
-    post conn, Routes.recipe_path(conn, :create), recipe: new_attrs
-    # 用戶 chenxsan 只能創(chuàng)建自己的菜譜,無法替 samchen 創(chuàng)建菜譜
-    assert Repo.get_by(Recipe, attrs)
-    # samchen 不應該有菜譜
-    refute Repo.get_by(Recipe, new_attrs)
-  end
 end

是的,繞了一圈,我們把前面新增的那個測試給刪除了,因為 Recipe.changeset 已經(jīng)不再接收 user_id,那個測試已經(jīng)失去意義。

那么,我們要怎樣將當前登錄的用戶 id 置入 recipe 中?

Ecto 提供了 build_assoc 方法,用于處理這類“關(guān)聯(lián)”,我們來改造下 recipe_controller.ex 文件:

diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
index c74b492..967b7bc 100644
--- a/web/controllers/recipe_controller.ex
+++ b/web/controllers/recipe_controller.ex
@@ -9,12 +9,18 @@ defmodule TvRecipe.RecipeController do
   end

   def index(conn, _params) do
-    recipes = Recipes.list_recipes()
+    recipes =
+      conn.assigns.current_user
+      |> assoc(:recipes)
+      |> TvRecipe.Repo.all()
 
     render(conn, "index.html", recipes: recipes)
   end

   def new(conn, _params) do
-    changeset = Recipe.changeset(%Recipe{})
+    changeset =
+      conn.assigns.current_user
+      |> build_assoc(:recipes)
+      |> Recipe.changeset()
     render(conn, "new.html", changeset: changeset)
   end

   def create(conn, %{"recipe" => recipe_params}) do
-    changeset = Recipe.changeset(%Recipe{}, recipe_params)
+    changeset =
+      conn.assigns.current_user
+      |> build_assoc(:recipes)
+      |> Recipe.changeset(recipe_params)

     case Repo.insert(changeset) do
       {:ok, _recipe} ->

你可能會好奇此時的 changeset,我們可以使用 IO.inspect(changeset) 在終端窗口中打印出來,它大致是這個樣子:

#Ecto.Changeset<action: nil,
 changes: %{content: "Phoenix Framework is awesome", episode: 2, name: "2", season: 2, title: "2"},
 errors: [], data: #TvRecipe.Recipe<>, valid?: true>

可是其中并沒有看到 user_id - 那么 build_assoc 構(gòu)建的數(shù)據(jù)存放在哪?在 changeset.data 下,大致是這樣:

%TvRecipe.Recipe{__meta__: #Ecto.Schema.Metadata<:built, "recipes">,
 content: nil, episode: nil, id: nil, inserted_at: nil, name: nil, season: nil,
 title: nil, updated_at: nil,
 user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 1}

咦,上面看起來有點像魔法。

但我們仔細閱讀 Repo.insert說明,可以看到如下一段:

In case a changeset is given, the changes in the changeset are merged with the struct fields, and all of them are sent to the database.

其中有個關(guān)鍵詞 merged,是的,存放在 changeset.data 下的數(shù)據(jù),會被合并進去,這解釋了我們 user_id 不在 changeset.changes 下,卻最終在數(shù)據(jù)庫中出現(xiàn)的魔法。

最后,我們還要調(diào)整些代碼,來檢驗新建的 recipe 中是否包含了當前登錄用戶的 id:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index d953315..b901b61 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -29,10 +29,10 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end

-  test "creates resource and redirects when data is valid", %{conn: conn} do
+  test "creates resource and redirects when data is valid", %{conn: conn, user: user} do
     conn = post conn, Routes.recipe_path(conn, :create), recipe: @valid_attrs
     assert redirected_to(conn) == Routes.recipe_path(conn, :index)
-    assert Repo.get_by(Recipe, @valid_attrs)
+    assert Repo.get_by(Recipe, Map.put(@valid_attrs, :user_id, user.id))
   end

運行測試:

mix test
.....................................................

Finished in 0.7 seconds
55 tests, 0 failures

全部通過。

我的 recipes

我們還有一個情況未處理,用戶 A 登錄后現(xiàn)在可以查看、編輯、更新用戶 B 的菜譜的,我們要禁止這些動作。

寫個測試驗證一下:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index b901b61..cdbc420 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -101,4 +101,19 @@ defmodule TvRecipeWeb.RecipeControllerTest do
     end)
   end

+  test "user should not allowed to show recipe of other people", %{conn: conn, user: user} do
+    # 當前登錄用戶創(chuàng)建了一個菜譜
+    conn = post conn, Routes.recipe_path(conn, :create), recipe: @valid_attrs
+    recipe = Repo.get_by(Recipe, Map.put(@valid_attrs, :user_id, user.id))
+    # 新建一個用戶
+    new_user_attrs = %{email: "chenxsan+1@gmail.com", "username": "samchen", password: String.duplicate("1", 6)}
+    Repo.insert! User.changeset(%User{}, new_user_attrs)
+    # 登錄新建的用戶
+    conn = post conn, Routes.session_path(conn, :create), session: new_user_attrs
+    # 讀取前頭的 recipe 失敗,因為它不屬于新用戶所有
+    assert_error_sent 404, fn ->
+      get conn, Routes.recipe_path(conn, :show, recipe)
+    end
+  end
 end

運行測試:

mix test
Compiling 1 file (.ex)
................................

  1) test user should not allowed to show recipe of other people (TvRecipeWeb.RecipeControllerTest)
     test/controllers/recipe_controller_test.exs:105
     expected error to be sent as 404 status, but response sent 200 without error
     stacktrace:
       (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2
       test/controllers/recipe_controller_test.exs:115: (test)

.....................

Finished in 0.8 seconds
56 tests, 1 failure

Oops,報錯了,我們期望響應是 404,卻得到 200。

那么該如何取得當前登錄用戶自有的菜譜?

既然我們前面已經(jīng)定義過用戶與菜譜的關(guān)聯(lián)關(guān)系,那么一切應該很容易才是。

是的,Ecto 提供了一個 assoc 方法,它能幫我們?nèi)〉糜脩絷P(guān)聯(lián)的所有菜譜。

我們調(diào)整下 recipe_controller.ex 文件:

diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
index 967b7bc..22554ea 100644
--- a/web/controllers/recipe_controller.ex
+++ b/web/controllers/recipe_controller.ex
@@ -33,7 +33,7 @@ defmodule TvRecipe.RecipeController do
   end

   def show(conn, %{"id" => id}) do
-    recipe = Repo.get!(Recipe, id)
+    recipe = assoc(conn.assigns.current_user, :recipes) |> Repo.get!(id)
     render(conn, "show.html", recipe: recipe)
   end

再運行測試:

$ mix test
..........................

  1) test shows chosen resource (TvRecipeWeb.RecipeControllerTest)
     test/controllers/recipe_controller_test.exs:45
     ** (Ecto.NoResultsError) expected at least one result but got none in query:

     from r in TvRecipe.Recipe,
       where: r.user_id == ^2469,
       where: r.id == ^"645"

     stacktrace:
       (ecto) lib/ecto/repo/queryable.ex:78: Ecto.Repo.Queryable.one!/4
       (tv_recipe) web/controllers/recipe_controller.ex:36: TvRecipe.RecipeController.show/2
       (tv_recipe) web/controllers/recipe_controller.ex:1: TvRecipe.RecipeController.action/2
       (tv_recipe) web/controllers/recipe_controller.ex:1: TvRecipe.RecipeController.phoenix_controller_pipeline/2
       (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4
       (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2
       (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2
       (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1
       (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2
       (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
       test/controllers/recipe_controller_test.exs:47: (test)

...........................

Finished in 0.8 seconds
56 tests, 1 failure

我們修復了前面一個錯誤,但因為我們的修復代碼導致了另一個新錯誤。

檢查測試代碼,可以發(fā)現(xiàn),舊的測試代碼已經(jīng)不適用了,因為它們新建的 recipe 不包含 user_id。我們調(diào)整一下:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index cdbc420..d93bbd1 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -42,8 +42,8 @@ defmodule TvRecipeWeb.RecipeControllerTest do
   end

   @tag logged_in: true
-  test "shows chosen resource", %{conn: conn} do
-    recipe = Repo.insert! %Recipe{}
+  test "shows chosen resource", %{conn: conn, user: user} do
+    recipe = Repo.insert! %Recipe{user_id: user.id}
     conn = get conn, Routes.recipe_path(conn, :show, recipe)
     assert html_response(conn, 200) =~ "Show recipe"
   end

再運行測試:

$ mix test
......................................................

Finished in 0.8 seconds
56 tests, 0 failures

通過了。

但上面我們只修改了 show 這個動作,其它幾個動作同樣需要修改:

diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex
index 22554ea..f317b59 100644
--- a/web/controllers/recipe_controller.ex
+++ b/web/controllers/recipe_controller.ex
@@ -4,7 +4,7 @@ defmodule TvRecipe.RecipeController do
   alias TvRecipe.Recipe

   def index(conn, _params) do
-    recipes = Repo.all(Recipe)
+    recipes = assoc(conn.assigns.current_user, :recipes) |> Repo.all()
     render(conn, "index.html", recipes: recipes)
   end

@@ -38,13 +38,13 @@ defmodule TvRecipe.RecipeController do
   end

   def edit(conn, %{"id" => id}) do
-    recipe = Repo.get!(Recipe, id)
+    recipe = assoc(conn.assigns.current_user, :recipes) |> Repo.get!(id)
     changeset = Recipe.changeset(recipe)
     render(conn, "edit.html", recipe: recipe, changeset: changeset)
   end

   def update(conn, %{"id" => id, "recipe" => recipe_params}) do
-    recipe = Repo.get!(Recipe, id)
+    recipe = assoc(conn.assigns.current_user, :recipes) |> Repo.get!(id)
     changeset = Recipe.changeset(recipe, recipe_params)

     case Repo.update(changeset) do
@@ -58,7 +58,7 @@ defmodule TvRecipe.RecipeController do
   end

   def delete(conn, %{"id" => id}) do
-    recipe = Repo.get!(Recipe, id)
+    recipe = assoc(conn.assigns.current_user, :recipes) |> Repo.get!(id)

     # Here we use delete! (with a bang) because we expect
     # it to always work (and if it does not, it will raise).

別忘了修復測試:

diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs
index d93bbd1..190ede9 100644
--- a/test/controllers/recipe_controller_test.exs
+++ b/test/controllers/recipe_controller_test.exs
@@ -56,30 +56,30 @@ defmodule TvRecipeWeb.RecipeControllerTest do
-defp create_recipe(%{attrs: attrs} = context) do
+defp create_recipe(%{conn: conn, attrs: attrs} = context) do
-  recipe = fixture(attrs)
+  conn = post conn, Routes.recipe_path(conn, :create), recipe: attrs
+  assert %{id: id} = redirected_params(conn)
+  recipe = Recipes.get_recipe!(id)

   context
   |> Map.put(:recipe, recipe)
 end
數(shù)據(jù)的完整性

前面我們在處理 user_id 時,順手刪除了 foreign_key_constraint,那么,我們要如何處理這樣一種情況:用戶提交創(chuàng)建菜譜的請求,但用戶突然被管理員刪除。這時我們的數(shù)據(jù)庫里就會出現(xiàn)無主的菜譜。我們希望避免這種情況。

我們在 recipe_test.exs 文件中重新增加一個測試:

diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs
index 4dbc961..2e2b518 100644
--- a/test/models/recipe_test.exs
+++ b/test/models/recipe_test.exs
@@ -1,7 +1,7 @@
 defmodule TvRecipe.RecipeTest do
   use TvRecipe.ModelCase

-  alias TvRecipe.{Recipe}
+  alias TvRecipe.Repo
+  alias TvRecipe.Users.User
+  alias TvRecipe.Recipes.Recipe

   @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"}
   @invalid_attrs %{}
@@ -41,4 +41,13 @@ defmodule TvRecipe.RecipeTest do
     assert {:content, "請?zhí)顚?} in errors_on(%Recipe{}, attrs)
   end

+  test "user must exist" do
+    changeset =
+      %User{id: -1}
+      |> Ecto.build_assoc(:recipes)
+      |> Recipe.changeset(@valid_attrs)
+    {:error, changeset} = Repo.insert changeset
+    assert %{user_id: ["does not exist"]} = errors_on(changeset)
+  end
+
 end

運行測試:

mix test
Compiling 13 files (.ex)
.............................................

  1) test user must exist (TvRecipe.RecipeTest)
     test/models/recipe_test.exs:44
     ** (Ecto.ConstraintError) constraint error when attempting to insert struct:

         * foreign_key: recipes_user_id_fkey

     If you would like to convert this constraint into an error, please
     call foreign_key_constraint/3 in your changeset and define the proper
     constraint name. The changeset has not defined any constraint.

     stacktrace:
       (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3
       (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2
       (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3
       (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4
       (ecto) lib/ecto/repo/schema.ex:684: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6
       (ecto) lib/ecto/adapters/sql.ex:615: anonymous fn/3 in Ecto.Adapters.SQL.do_transaction/3
       (db_connection) lib/db_connection.ex:1274: DBConnection.transaction_run/4
       (db_connection) lib/db_connection.ex:1198: DBConnection.run_begin/3
       (db_connection) lib/db_connection.ex:789: DBConnection.transaction/3
       test/models/recipe_test.exs:49: (test)

.........

Finished in 0.7 seconds
57 tests, 1 failure

很好,foreign_key_constraint 的提示又出來了。修改 recipe.ex 文件:

diff --git a/web/models/recipe.ex b/web/models/recipe.ex
index 8d34ed2..fcc97ad 100644
--- a/web/models/recipe.ex
+++ b/web/models/recipe.ex
@@ -19,5 +19,6 @@ defmodule TvRecipe.Recipe do
     struct
     |> cast(params, [:name, :title, :season, :episode, :content])
     |> validate_required([:name, :title, :season, :episode, :content], message: "請?zhí)顚?)
+    |> foreign_key_constraint(:user_id)
   end
 end

再次運行測試:

mix test
Compiling 13 files (.ex)
.......................................................

Finished in 0.7 seconds
57 tests, 0 failures

悉數(shù)通過。至此,我們完成了 RecipeController 的測試。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號